Skip to content

feat(backend): postgres integration - #12379

Merged
HumairAK merged 1 commit into
kubeflow:masterfrom
kaikaila:feature/postgres-integration
Sep 8, 2026
Merged

HumairAK merged 1 commit into
kubeflow:masterfrom
kaikaila:feature/postgres-integration

Conversation

@kaikaila

@kaikaila kaikaila commented Oct 19, 2025 •

Copy link
Copy Markdown
Contributor

Summary

This PR adds full PostgreSQL (pgx driver) support to Kubeflow Pipelines backend, enabling users to choose between MySQL and PostgreSQL as the metadata database. The implementation introduces a clean dialect abstraction layer and includes a major query optimization that benefits both database backends.

Fixes #7512
Fixes #9813

Key achievements:

What Changed

1. Storage Layer Refactoring - Dialect Abstraction

  • Introduced a DBDialect interface encapsulating database-specific identifier quoting, placeholders, and aggregation.
  • Dialect-aware filter builders (backend/src/apiserver/storage/list_filters.go).

2. ListRuns Query Performance Optimization

  • Optimizes adding metrics, resource references, and tasks by performing LEFT JOINs solely on PrimaryKey UUIDs + aggregated columns, and applying the final INNER JOIN to fetch LONGTEXT spec columns.

3. Deployment & CI Configurations

  • Standalone / Multi-user Kustomize manifests under platform-agnostic-postgresql.
  • Adapted developer targets (make DATABASE=postgres dev-kind-cluster).
  • Added CI workflow variants utilizing GitHub actions matrix db_type: ["mysql", "pgx"].

4. Consistency & Inconsitency across backend databases

  • casefolding, see here
  • null in sorting: Deterministic NULL ordering for sorts across MySQL/PostgreSQL (e.g. runs missing the sorted metric always sort last).
  • collation details (-vs _), see here

Testing

  • Extended integration-tests-v1 and V2 api tests to utilize both databases (with cache matrices). Unit coverage expanded.

Migration Guide

  • New PostgreSQL deployments must use an operator overlay that explicitly sets pipeline-install-config.data.postgresExtraParams with an sslmode before applying platform-agnostic-postgresql. The base overlay intentionally fails closed when this is omitted; see its README for an example.
  • Existing MySQL deployments remain fully backward-compatible with no actions required.
  • Behavior change (sorting by run metric only): When listing runs sorted by a metric, runs that do not have the selected metric (SQL NULL) are now always ordered last, in both ascending and descending order, consistently across MySQL and PostgreSQL. Previously this relied on each database's default NULLplacement, so MySQL placed such runs first in ascending order. Only the relative position of runs missing the sorted metric changes; runs that have the metric are unaffected. This also fixes a bug where paging past a run without the selected metric failed with cannot sort by field instead of returning the next page. Sorting by regular fields (name, timestamps, etc.) is unchanged.

Preceding PRs

Follow-up Issues, PRs, and Discussions

Guide for Reviewers

@google-oss-prow

Copy link
Copy Markdown

Hi @kaikaila. Thanks for your PR.

I'm waiting for a kubeflow member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should join the org to skip this step.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes/test-infra repository.

@github-actions

Copy link
Copy Markdown

🚫 This command cannot be processed. Only organization members or owners can use the commands.

@kaikaila
kaikaila force-pushed the feature/postgres-integration branch 7 times, most recently from cd1d08b to 85498ed Compare October 22, 2025 05:03
@kaikaila

Copy link
Copy Markdown
Contributor Author

Currently, both MySQL and PGX setups use the DB superuser for all KFP operations, which is why client_manager.go contains a “create database if not exist” step here.

From a security standpoint, would it be preferable to:

  1. Move DB creation out of the client manager and into the deployment/init phase (i.e. add a manifests/kustomize/third-party/postgresql/base/pg-init-configmap.yaml) and
  2. Introduce a dedicated restricted user for KFP components, limited to the mlpipeline database?

If the team agrees, I can propose a follow-up PR to refactor accordingly.

@HumairAK

Copy link
Copy Markdown
Collaborator

I'm fine with this, I don't think it's great that KFP tries to create a database (or a bucket frankly)

fyi @mprahl / @droctothorpe

@kaikaila

Copy link
Copy Markdown
Contributor Author

Thanks, @HumairAK — totally agree on the security point.
Since this PR is already getting quite heavy, would you be okay if I leave the user permission changes for a separate follow-up PR?

@kaikaila
kaikaila force-pushed the feature/postgres-integration branch 3 times, most recently from 09fd370 to 1e0caa8 Compare October 23, 2025 07:10
@HumairAK

Copy link
Copy Markdown
Collaborator

yes that is fine

@kaikaila
kaikaila force-pushed the feature/postgres-integration branch 6 times, most recently from 4d33821 to e6c943c Compare October 24, 2025 02:47
@kaikaila

Copy link
Copy Markdown
Contributor Author

Question about the PostgreSQL test workflow organization

Current situation

The V2 integration tests for PostgreSQL logically belong in a "PostgreSQL counterpart" to legacy-v2-api-integration-tests.yml
However, I didn't want to create a new workflow with "legacy" in the name from day one.
As a temporary solution, I merged them into api-server-test-Postgres.yml
This causes asymmetry with api-server-tests.yml and the workflow has mixed responsibilities.

Question: What's the recommended workflow organization for PostgreSQL tests?

Should I:

  • a. Create legacy-v2-api-integration-tests-postgres.yml for consistency (even though it's new)?
  • b. Keep current structure and accept the asymmetry?
  • c. Refactor both MySQL and PostgreSQL to a unified structure?

Would love guidance on the long-term vision for test workflow organization, especially from @nsingla

Comment thread .github/resources/scripts/deploy-kfp.sh Outdated
fi

# Manifests will be deployed according to the flag provided
# Manifest selection: each branch picks ONE pre-built kustomize overlay directory.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @HumairAK, regarding the if-else chain in deploy-kfp.sh (lines 144-221) — I've been thinking about how to improve it but haven't landed on a good approach.

The chain is ugly, but it maps directly to the CI test matrix combinations. Since this script is only used by CI, refactoring it into a compositional "building blocks" approach has limited ROI. More importantly, it's not straightforward to do — options like tls-enabled and postgresql each use a different kustomize base (platform-agnostic-standalone-tls vs platform-agnostic-postgresql), so they can't simply be stacked as independent overlays.

For now I've added a comment block before the if-else chain explaining why it's mutually exclusive priority matching rather than free combination of flags, so future readers aren't confused by the structure.

Do you have any suggestions for improving this, or are you okay leaving it as-is with the added comments?

Comment thread backend/src/cache/client/sql.go Outdated
Comment on lines +25 to +31
// quoteDSNValue quotes a value for use in a libpq keyword/value connection string.
// Per libpq rules: wrap in single quotes, escape \ and ' with a backslash.
func quoteDSNValue(v string) string {
v = strings.ReplaceAll(v, `\`, `\\`)
v = strings.ReplaceAll(v, `'`, `\'`)
return "'" + v + "'"
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quoting in dialect.go is for SQL queries — QuoteIdentifier escapes SQL identifiers (table/column names), and EscapeSQLString escapes SQL string literals. These are meant to be sent to the database engine for execution.

The quoting in the DSN is for the libpq connection string — it follows libpq's own parsing rules, which have nothing to do with SQL syntax. libpq's rule is: wrap values in single quotes, and escape ' and \ within values using a \ prefix.

These are two fundamentally different escaping schemes, so the dialect package methods should not be reused here. The DSN value escaping belongs in its own small helper, co-located in sql.go.

Comment on lines +190 to 195
// Wait a bit more to ensure the first run's launcher has finished writing cache to database
// The run state becomes SUCCEEDED when the user container finishes, but launcher still needs
// time to publish results and create cache entry in the database
time.Sleep(15 * time.Second)

state := s.getContainerExecutionState(t, allRuns[1].RunID)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suggestion has limited value in this scenario — the real timing risk isn't in this 15-second sleep, but in whether Run#1's cache entry has been written to the database by the time Run#2 starts. That timing is controlled by the recurring run's 60-second interval, not by the test code, so polling here wouldn't address the actual race condition.

Comment on lines 297 to 303
tests := []struct {
name string
tasks []*model.Task
runID string
want []*model.Task
wantErr bool
errMsg string

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the field is deleted, there will be many compiler errors. Instead, I replaced defaultFakeRunIdTwo with tt.runID in line 514.

Comment thread backend/src/apiserver/list/list.go Outdated
// Also check runtime value type as fallback for old tokens that lack this field.
_, valueIsString := o.SortByFieldValue.(string)
if o.SortByFieldIsString || valueIsString {
sqlBuilder = sqlBuilder.OrderBy(fmt.Sprintf("LOWER(%v) %v", sortByFieldNameWithPrefix, order))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is intentionally put in the shared layer to keep case-insensitive behavior across all resources, not just pipelines. Following the decision here.

Comment thread .github/resources/scripts/deploy-kfp.sh Outdated
fi

# Manifests will be deployed according to the flag provided
# Manifest selection: each branch picks ONE pre-built kustomize overlay directory.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks! I looked into pinning to a release tag (e.g. the latest v1.10.2 is released in July 2025 ), but it turns out the profile-controller path (applications/dashboard/upstream/profile-controller/...) was only added to kubeflow/manifests master in April 2026 and has never been included in any release tag. So pinning to a tag would break that path with a 404.

Instead, I've pinned all four kubectl apply -k calls to a specific commit SHA (June 2026), which gives us reproducibility without requiring a tagged release.

assert.Equal(t, 5, totalSize)
assert.Equal(t, "arguments-parameters.yaml", listFirstPagePipelines[1].Name)
assert.Equal(t, "arguments_parameters.zip", listFirstPagePipelines[0].Name)
// MySQL: _ sorts before - (ascending), so zip comes first at index 0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me check with @nsingla — you introduced these fixture names in #12440. Was there a specific reason we needed arguments-parameters.yaml / arguments_parameters.zip as the sort sentinels, or is it safe to rename them to alphanumeric-only names to avoid collation sensitivity? If there's no hard dependency, I'll swap them out for simpler names.

if s, ok := v.(string); ok {
col := QualifyIdentifier(quote, k)
andExprs = append(andExprs, squirrel.Expr(
fmt.Sprintf("LOWER(%s) = LOWER(?)", col), s,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked separately in #13512, which makes matchesFilter() case-insensitive to match the SQL-backed LOWER() behavior. Let's continue the discussion there.

num_parallel_nodes: ${{ env.NUMBER_OF_PARALLEL_NODES }}
default_namespace: ${{ env.NAMESPACE }}
python_version: ${{ env.PYTHON_VERSION }}
report_name: "K8Native_k8sVersion=${{ matrix.k8s_version }}_cacheEnabled=${{ matrix.cache_enabled }}_argoVersion=${{ matrix.argo_version }}_uploadPipelinesWithKubernetesClient=${{ matrix.uploadPipelinesWithKubernetesClient }}"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @mprahl Good Catch. Though, these are pre-existing issues on master, which are out of scope of this PR. I've raised them separately in #13594.

Separately, the k8s-native job currently has no db_type axis, but it still relies on a database for storing runs, experiments, jobs, and other non-pipeline resources. Should we add an explicit db_type axis to this job (starting with MySQL only, and expanding to PostgreSQL in a follow-up)?

Comment thread backend/README.md
psql -h 127.0.0.1 -p 5432 -U user -d mlpipeline
```

When prompted for a password, enter: `password`

@kaikaila kaikaila Jun 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For context, @HumairAK and I discussed the least-privilege refactor earlier (proposal — the plan is to move CREATE DATABASE out of client_manager.go into the deployment/init phase and introduce a dedicated restricted user in a follow-up PR.

I think the docs change you're requesting here should stay in sync with that code change. If we only update the docs to instruct users to create a least-privileged user now, the API server would fail at CREATE DATABASE which requires superuser privileges.

So I suggest deferring both docs & code land together in the follow-up PR, tracking in #13797

Comment thread backend/src/apiserver/list/list.go Outdated
var sortByField interface{}
if sortByField = listable.GetFieldValue(o.SortByFieldName); sortByField == nil {
return nil, util.NewInvalidInputError("cannot sort by field %q on type %q", o.SortByFieldName, elemName)
if sortByField = listable.GetFieldValue(fieldNameForValue); sortByField == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GPT 5.6 Sol Review

When sorting by a metric, the SQL deliberately produces NULL for runs without that metric, but this path rejects the lookahead row while generating the next-page token. If the first omitted run has no matching metric, an otherwise valid ListRuns request fails with cannot sort by field instead of returning a token. PostgreSQL and MySQL also place NULLs differently by default, so this varies by backend and direction. Could we define deterministic NULL ordering, represent NULL in the token, and add multi-page tests containing runs both with and without the selected metric?

operation = func() error {
_, err = db.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName))
if ignoreAlreadyExistError(dialect, err) != nil {
_, err = db.Exec(fmt.Sprintf("CREATE DATABASE %s", drvDialect.QuoteIdentifier(dbName)))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GPT 5.6 Sol Review

This unconditionally executes CREATE DATABASE even when the target database has already been provisioned. A normal runtime role that owns the existing database but lacks CREATEDB receives permission denied to create database; the cache server has the same behavior. This forces the bundled deployment to give KFP a PostgreSQL superuser and prevents common managed/least-privilege configurations. Could database creation move to an initialization/admin phase, or could we support a pre-provisioned-database mode so runtime components can use restricted roles?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see here.

Comment thread backend/src/apiserver/client/sql.go Outdated
extraParams map[string]string,
) (*pgx.ConnConfig, string, error) {
q := url.Values{}
q.Set("sslmode", "disable")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GPT 5.6 Sol Review

The new PostgreSQL client still defaults to sslmode=disable, so production-facing deployments send database traffic without transport encryption unless operators discover and override the extra parameter. The cache helper has the same default, and the new overlays do not set a safer value. Could the development manifest opt into disable explicitly while the client uses a secure default (or requires an explicit SSL mode), with documented verify-full support for production?

@kaikaila kaikaila Jul 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done for postgres: require sslmode to be set explicitly instead: the function now returns an error (fail-fast / CrashLoop) when sslmode is absent, forcing a deliberate choice ("disable" for local dev, "verify-full" for production).
Open #13796 to track the parity feature for MySQL.

Comment thread backend/src/cache/client_manager.go Outdated
operation = func() error {
_, err = db.Exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", dbName))
var pgxExtraParams = map[string]string{}
json.Unmarshal([]byte(params.dbExtraParams), &pgxExtraParams)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GPT 5.6 Sol Review

The JSON parse error is discarded here. A typo in --db_extra_params, including intended TLS options such as sslmode=verify-full, silently produces an empty map and falls back to sslmode=disable; the operator receives no indication that the requested security configuration was ignored. Could startup fail on malformed JSON and cover malformed PostgreSQL and MySQL parameter maps in tests?

for _, v := range vs {
if s, ok := v.(string); ok {
col := QualifyIdentifier(quote, k)
andExprs = append(andExprs, squirrel.Expr(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GPT 5.6 Sol Review

Applying LOWER() to every string predicate changes exact-match semantics for identifiers and enums such as UUIDs, namespaces, and storage states. On PostgreSQL it can also prevent ordinary indexes and primary-key indexes from serving those filters. User-facing fields such as display names may reasonably be case-insensitive, but identifiers should retain direct comparison. Could we whitelist fields intended to be case-insensitive and preserve exact comparisons for identifiers/enums?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see here

@juliusvonkohout juliusvonkohout left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One problem i saw recently was a bit of manifest duplication. E.g we now have 2 pipeline-install configmaps in the repository instead of patching the existing one. So please make sure that you do not duplicate manifests and reduce existing duplication for postgres.

@kaikaila

Copy link
Copy Markdown
Contributor Author

One problem i saw recently was a bit of manifest duplication. E.g we now have 2 pipeline-install configmaps in the repository instead of patching the existing one. So please make sure that you do not duplicate manifests and reduce existing duplication for postgres.

Hi @juliusvonkohout Good catch! I removed the file in the latest push.

Comment thread .github/workflows/api-server-tests.yml Outdated
argo_version: [ "v3.7.14", "v4.0.5" ]
pipeline_store: [ "database" ]
pod_to_pod_tls_enabled: [ "false" ]
db_type: ["mysql", "pgx"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add this option to the e2e-tests.yml as well

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upgrade tests should also have this option as well

@kaikaila kaikaila Jul 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added db_type: "pgx" to all three E2E jobs in e2e-test.yml using include entries (one representative combination per job).

The current approach uses include enumeration rather than a top-level matrix dimension because pgx kustomize overlays don't exist for every dimension combination yet:

Standalone: pgx is incompatible with cache_disabled, proxy, and pod_to_pod_tls (no corresponding overlays in standalone/postgresql/)
Multi-user: pgx is incompatible with both cache_disabled and artifact_proxy (no overlays in multiuser/postgresql/
), so the multi-user pgx entry must explicitly disable artifact_proxy
Each pgx include has a TODO comment noting which overlays would need to be created to expand coverage. If broader coverage is preferred for this PR (e.g., promoting db_type to a top-level dimension with excludes for the standalone job, similar to api-server-tests.yml), happy to expand — that would add ~8 runners for Job 1. Alternatively, we can expand incrementally in follow-up PRs as more overlays are added and keep a tracking issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pgx option to upgrade test doesn't make sense yet: since no release has ever successfully run with PostgreSQL, there is no pre-existing PostgreSQL data to migrate.

Plan: Once this PR merges and a release with full PostgreSQL support is published (expected 2.18), there will be a real upgrade path to validate. I'll file a tracking issue to make sure this doesn't fall through the cracks.

Fresh pgx deployment and functionality are already covered by e2e-test.yml, api-server-tests.yml, and integration-tests-v1.yml (which also skips its upgrade test for pgx via -skip TestUpgrade).

cache_enabled: "true"
pod_to_pod_tls_enabled: "true"
db_type: "mysql"
exclude:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are these excluded? is this an incompatibility that's documented?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These combinations are excluded because the corresponding CI kustomize overlays don't exist yet. Specifically:

  • pgx + proxy=true — no standalone/postgresql/proxy overlay
  • pgx + cache_enabled=false — no standalone/postgresql/cache-disabled overlay
    The deploy script (deploy-kfp.sh) also has explicit guard-rails that fail fast if someone tries to run these unsupported combinations, so this isn't a silent gap.

For this PR I intentionally kept the PostgreSQL CI coverage to the core path (cache_enabled=true, proxy=false) to validate the integration end-to-end without expanding the overlay surface all at once. The multi-user section has a TODO comment noting the same (missing pgx + cache_disabled entry).

Happy to create a follow-up issue to track adding the remaining overlay variants (proxy, cache-disabled, TLS) — would that work?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern with that is that until its tested, we cannot be certain that its a supported path, unless you've tested it manually to confirm that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that an untested path shouldn't be presented as supported. I've documented it explicitly on the operator-facing side: kubeflow/website#4438. The website PR is written against the state after this PR merges to master, so I'll hold it until then.

Since no multi-user pod-to-pod TLS overlay exists for any database backend, I also raised an issue here.

Comment thread .github/workflows/api-server-tests.yml Outdated
strategy:
matrix:
k8s_version: [ "v1.36.1"]
k8s_version: [ "v1.36.1" ]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should add db_type here as well

@kaikaila

kaikaila commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Review Guide

This PR touches ~150 files. To make review manageable, changes are grouped by module below. Recommended review order: start with the dialect abstraction (the foundation), then stores, then tests, then deployment.

1. Dialect Abstraction Layer (start here)

Core abstraction that all other changes depend on.

Files What it does
backend/src/apiserver/common/sql/dialect/dialect.go New DBDialect type — identifier quoting, placeholder format (? vs $N), GROUP_CONCAT vs string_agg, expression concat
backend/src/apiserver/common/sql/dialect/dialect_test.go Unit tests for dialect behaviors
backend/src/apiserver/common/sql/config.go Unified DB config builder — MySQL DSN and PostgreSQL pgx.ConnConfig with sslmode
backend/src/apiserver/common/sql/config_test.go Config builder tests
backend/src/apiserver/storage/sql_dialect_util.go Dialect-aware upsert, SELECT FOR UPDATE, duplicate error detection, UPDATE ... JOIN vs UPDATE ... FROM
backend/src/apiserver/storage/sql_dialect_util_test.go Tests for the above

Deleted (replaced by the above):

  • backend/src/apiserver/storage/db.go, db_test.go — old SQLDialect interface
  • backend/src/apiserver/client/sql.go, client/sql_test.go — old MySQL-only config builder
  • backend/src/apiserver/client_manager/dialect.go, dialect_test.go — old dialect plumbing

2. Store Layer Updates

Each store updated to accept DBDialect and use dialect-aware query building.

Files Key changes
storage/run_store.go (+410/-257) Largest change — rewrote metric sort from GROUP_CONCAT to MAX(CASE WHEN ... END) + LEFT JOIN. Performance optimization that benefits both MySQL and PostgreSQL
storage/pipeline_store.go Dialect-aware upsert, quoted identifiers
storage/experiment_store.go Same pattern
storage/job_store.go Same pattern
storage/task_store.go Same pattern
storage/resource_reference_store.go Same pattern
storage/db_status_store.go Same pattern
storage/default_experiment_store.go Same pattern
storage/db_fake.go Test fake updated for new interface
storage/*_test.go Corresponding test updates

3. Cross-Database Behavioral Differences

MySQL and PostgreSQL differ in three areas that required explicit handling (see PR description for details):

  • Case folding: PostgreSQL = is case-sensitive; MySQL's utf8_general_ci is not. Addressed with LOWER() wrapping.
  • NULL sorting: NULL placement in ORDER BY differs. Runs missing a sorted metric now always sort last on both backends.
  • Collation (- vs _): PostgreSQL and MySQL sort punctuation differently, affecting paginated list results.
Files What it does
filter/filter.go, filter_test.go Filter-to-SQL now wraps case-insensitive fields with LOWER()
list/list.go, list_test.go List queries apply case-insensitive comparison; NULL-last ordering
storage/list_filters.go, list_filters_test.go Dialect-aware filter SQL generation
storage/run_store.go NULL-last metric sorting via COALESCE / NULLS LAST
model/*.go (6 files) Each model adds CaseInsensitiveFields() method
test/integration/pipeline_api_test.go Sort-order assertions fixed for collation differences

4. Client Manager & Cache Service

Initialization and cache service plumbing for dual-DB support.

Files What it does
client_manager/client_manager.go InitDBClient returns (*sql.DB, DBDialect), postgres driver registration, expression indexes
resource/resource_manager.go Updated for new client manager interface; also includes an error logging improvement for failed workflows
cache/client_manager.go Same dual-DB init pattern for cache service
cache/storage/* Cache store dialect adaptation

Deleted: cache/client/sql.go (consolidated into common/sql/config.go)

5. Test Infrastructure & Integration Tests

Test stability fixes triggered by postgres's different runtime behavior (stricter collation, different timing).

Files What it does
test/v2/test_utils.go Cleanup functions now poll with timeout instead of fire-and-forget (race conditions surfaced by different timing)
test/v2/integration/cache_test.go Cleanup ordering fix, timing adjustment for cache writes, localhost resolution fix
test/v2/integration/experiment_api_test.go Cleanup ordering: delete recurring runs before runs
test/v2/integration/recurring_run_api_test.go Same cleanup ordering fix
test/v2/api/pipeline_run_api_test.go New tests for placeholder numbering correctness ($1/$2/$3) and un-pended list/filter/pagination tests
test/v2/api/pipeline_api_test.go Case-insensitive filter tests, created_at filter format fix
test/integration/pipeline_api_test.go Sort-order assertions fixed for postgres collation
test/testutil/test_utils.go Skip binary files during regex replacement (discovered when tests hit .zip pipeline files)

6. Deployment Manifests & CI

Can be reviewed independently from Go code.

Files What it does
manifests/kustomize/env/cert-manager/platform-agnostic-postgresql/ New standalone postgres kustomize overlay
manifests/kustomize/env/cert-manager/platform-agnostic-multi-user-postgresql/ New multi-user postgres overlay
manifests/kustomize/env/dev-kind/ Restructured for platform (linux/macOS) x database (mysql/postgres) matrix
manifests/kustomize/third-party/postgresql/ Updated base postgres deployment
.github/workflows/*.yml db_type matrix parameter across CI workflows
.github/actions/deploy/action.yml db_type input for deploy action
.github/resources/manifests/ CI-specific postgres overlays
backend/Makefile DATABASE variable, platform auto-detection

Deleted (replaced by new structure):

  • manifests/kustomize/base/installs/generic/postgres/
  • manifests/kustomize/base/postgresql/

7. Documentation

Files What it does
backend/src/apiserver/storage/README.md New — storage layer guidelines for dual-DB development
backend/README.md PostgreSQL dev setup, VSCode launch config
AGENTS.md Database query guidelines (GORM vs Squirrel + DBDialect)
test/integration/README.md Naming guidelines to avoid collation-sensitive test failures

Comment on lines +561 to +568
if pluginsInput.Valid {
lt := model.LargeText(pluginsInput.String)
run.PluginsInputString = &lt
}
if pluginsOutput.Valid {
lt := model.LargeText(pluginsOutput.String)
run.PluginsOutputString = &lt
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates the above lines.

Suggested change
if pluginsInput.Valid {
lt := model.LargeText(pluginsInput.String)
run.PluginsInputString = &lt
}
if pluginsOutput.Valid {
lt := model.LargeText(pluginsOutput.String)
run.PluginsOutputString = &lt
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PostgreSQL CI overlays (both standalone and multiuser) are missing the ../../base/ci-stability-tuning.yaml patch that all other CI overlays include. This patch increases probe timeouts and failure thresholds to prevent flaky CI failures under load. Without it, PostgreSQL CI jobs may fail intermittently when the API server takes longer to start.

Verified: standalone/default/kustomization.yaml includes - path: ../../base/ci-stability-tuning.yaml but standalone/postgresql/kustomization.yaml does not. Same for multiuser/postgresql.

Suggested fix: Add to both standalone/postgresql/kustomization.yaml and multiuser/postgresql/kustomization.yaml under patches::

  - path: ../../base/ci-stability-tuning.yaml


// DBDialect holds read-only runtime configuration for a SQL backend.
// All fields are private; callers must use the exported getter methods.
type DBDialect struct {

@hbelmiro hbelmiro Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DBDialect should be an interface, not a concrete struct. The codebase already had a SQLDialect interface in backend/src/apiserver/storage/db.go that used method dispatch for dialect-specific behavior. This PR replaced it with a concrete struct that uses switch d.name blocks and stores QuoteIdentifier as a lambda — a regression from the existing design.

Currently DBDialect uses switch d.name blocks inside ConcatAgg and ConcatExprs, each with a default: panic(...) branch. The QuoteFunction type alias exists only because quoting is stored as a function pointer rather than being a method.

With an interface + per-dialect implementations (mysqlDialect, pgxDialect, sqliteDialect):

type DBDialect interface {
    Name() string
    QuoteIdentifier(string) string
    LengthFunc() string
    QueryBuilder() sq.StatementBuilderType
    ExistDatabaseErrHint() string
    StringCollation() string
    ConcatAgg(distinct bool, expr, sep string) string
    ConcatExprs(exprs []string, sep string) string
}

This eliminates:

  • 2 switch d.name blocks (replaced by method dispatch)
  • 2 default: panic(...) branches in methods (the compiler enforces every dialect implements every method)
  • The QuoteFunction type alias (quoting becomes a regular method)

isDuplicateError and insertUpsert in sql_dialect_util.go could also become interface methods, which would isolate driver-specific imports (go-sql-driver/mysql, pgconn, go-sqlite3) into each concrete type rather than concentrating them in one file.

sqlBuilder = opts.AddOrderByToSelect(sqlBuilder, q, s.dbDialect.StringCollation())
}
// Apply correct placeholder format for the final SQL generation
if s.dbDialect.Name() == "pgx" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four locations manually check s.dbDialect.Name() == "pgx" to re-apply PlaceholderFormat(sq.Dollar) after subquery construction: run_store.go:228, run_store.go:248, job_store.go:185, job_store.go:201. This is a dialect abstraction leak — stores should not inspect the dialect name.

Suggested fix: Add a helper to DBDialect (e.g., FinalizeSelect(sq.SelectBuilder) sq.SelectBuilder) that applies the correct final placeholder format. Replace all four string-comparison checks with the helper call.

// Otherwise, return nil.
func ignoreAlreadyExistError(dialect SQLDialect, err error) error {
if err != nil && strings.Contains(err.Error(), dialect.ExistDatabaseErrHint) {
func ignoreAlreadyExistError(dialect sqldrv.DBDialect, err error) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignoreAlreadyExistError uses strings.Contains(err.Error(), "already exists") for PostgreSQL. The PR already uses structured pgerrcode checking in isDuplicateError (sql_dialect_util.go). This function should use pgerrcode.DuplicateDatabase for consistency and robustness.

Suggested fix: Use errors.As with *pgconn.PgError and check pe.Code == pgerrcode.DuplicateDatabase for the pgx case (both packages are already in go.mod).

Comment thread AGENTS.md Outdated

```go
func (s *ExperimentStore) ArchiveExperiment(id string) error {
quotedTable := s.dialect.QuoteIdentifier("Experiments")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Squirrel example in the "Database query guidelines" section has multiple errors that would produce broken code on PostgreSQL if copied:

  • s.dialect.QuoteIdentifier("Experiments") — table name is "experiments" (lowercase), and the field is s.dbDialect, not s.dialect
  • .Set(quotedState, ...) — should be .SetMap(sq.Eq{...})

The GORM example at line 703 uses Where("uuid = ?", uuid) with a raw lowercase column name. GORM maps struct fields through tags so this would actually use the tagged column "UUID", but the example is misleading — a reader might think raw lowercase column names work in Squirrel queries too.

Comment thread backend/src/apiserver/storage/README.md Outdated

### Database Schema Naming Convention

**Important**: KFP uses **CamelCase** table and column names (e.g., `Experiments`, `ExperimentUUID`) as a legacy design choice from the original MySQL implementation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"KFP uses CamelCase table and column names (e.g., Experiments, ExperimentUUID)" — table names are actually lowercase (experiments, pipelines, run_details, pipeline_versions). Only column names are CamelCase. This distinction matters because a developer quoting "Experiments" on PostgreSQL will get a "relation does not exist" error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The production PostgreSQL manifests (platform-agnostic-postgresql and platform-agnostic-multi-user-postgresql) don't set DBCONFIG_POSTGRESQLCONFIG_EXTRAPARAMS with sslmode. The code at common/sql/config.go:60 requires sslmode explicitly and crashes without it. Anyone deploying with kubectl apply -k manifests/kustomize/env/platform-agnostic-postgresql — as documented in the PR's Migration Guide — will get a CrashLoopBackOff.

CI passes because the CI overlays (.github/resources/manifests/standalone/postgresql/apiserver-env.yaml) add sslmode=disable via their own patches. The cache server has the same issue — cache-server-patch.yaml doesn't pass --db_extra_params='{"sslmode":"disable"}', so it will also crash.

Suggested fix: Set sslmode=disable in the production manifests with a comment to override for production TLS — the same approach MySQL uses (ships with working defaults, documents what to change for production). The current "secure-by-default" design that crashes instead of starting is not secure, it's broken — no user can deploy PostgreSQL without first discovering they need an undocumented env var override.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds good to me. We can create a follow up GitHub issue which adds manifests for KFP with Postgres with TLS enabled and using cert-manager to provision the certs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked as a follow-up in #13822

test_label: "E2ECritical"
# PostgreSQL lane — cache_enabled=true only (no pgx overlay for
# cache-disabled, proxy, or TLS).
# TODO: expand when additional standalone/postgresql/* overlays exist.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't they exist? Shouldn't this be part of this PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initial PostgreSQL scope intentionally covers only the core deployment configuration. The missing combinations (cache-disabled, proxy, pod-to-pod TLS, and multi-user artifact proxy) require dedicated PostgreSQL overlays and CI coverage.

They are tracked in follow-up issue #13822, which is also listed in this PR’s description.

root_password: password No newline at end of file
stringData:
# This user is used by KFP components (like apiserver) to connect to the database.
# TODO(kaikaila): make it a normal user instead of a superuser.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this TODO a leftover?

@kaikaila kaikaila Aug 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it’s intentional and tracked as a follow-up in #13797.

@juliusvonkohout

Copy link
Copy Markdown
Member

@hsinhoyeh for collaboration and #13956

@hsinatfootprintai

Copy link
Copy Markdown

@hsinhoyeh for collaboration and #13956

@kaikaila Just checking in on this PR—what's the current status?

@kaikaila

Copy link
Copy Markdown
Contributor Author

@hsinatfootprintai in progress. Thanks for your patience

@kaikaila

Copy link
Copy Markdown
Contributor Author

Hi @hbelmiro — PR #12379 is ready for another review. I addressed the comments from your previous review round.

I split the current branch into two commits to keep the review focused:

  1. d63ea562f: the main PostgreSQL integration. It was rebased onto current master; resolving the resulting upstream conflicts, including the GC-related changes from feat(backend): add database garbage collection for expired pipeline runs #13532, was necessary.
  2. 4449a5b48: follow-up changes addressing the previous review comments.

Key updates:

Thanks for taking another look.

@hbelmiro hbelmiro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two PostgreSQL schema concerns found during review.

Comment thread backend/src/apiserver/client_manager/client_manager.go Outdated
Comment thread backend/src/apiserver/client_manager/client_manager.go Outdated
Signed-off-by: kaikaila <lyk2772@126.com>

Signed-off-by: Yunkai Li <yunli@redhat.com>

@hbelmiro hbelmiro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/lgtm
/approve

@google-oss-prow

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: hbelmiro
Once this PR has been reviewed and has the lgtm label, please ask for approval from humairak. For more information see the Kubernetes Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-passed All CI tests on a pull request have passed do-not-merge/hold lgtm ok-to-test size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tracking: PostgreSQL support in KFP feat(backend): Add support of postgresql